前陣子碰到一個需求,希望使用者能夠一次閱覽多筆的訂單(比對資料、快速查看、或複製等操作),但又不想離開頁面,彈出視窗也不適合,於是逛來逛去就發現 Tab 這個打造好的 component:
然後又看到瀏覽器上面一排的頁籤:
心想:「嗯?好像可以...不如做一個可新增刪除的 Tab 吧!」
類似瀏覽器頁籤的做法,實際上要:
(還有一些留到下篇 (。・`ω´・。))
既然是瀏覽訂單,通常都會有List,而點擊 List 其中一個項目會新增一個 Tab 到 Tabs,Panel 則依照當下的選擇顯示對應的資料。
若是使用現有的元件要做出動態的 Tabs 時,要留意該元件有無相關的 prop 可以進行處理,例如 Chakra-UI 的 Tab 是複合型的(compound components)且設計上偏 static,因此在實現這個需求時 custom 就會比較麻煩一點,比對下來反而 MUI 的彈性就高很多,使用既有的元件要留意一下,或者也可以自己從頭建立就不用擔心這些問題 XD。
呈現 List 的部分就是一坨假資料,每一筆大致會有訂單編號、日期、客人、商品等資訊,格式大概這樣:
{
id: "6e2e49b7-1c7e-4d29-b7d9-ab4e95fd1bd7",
no: "OR20220619",
date: "2022-06-19T09:58:52+08:00",
customer: {
name: "Julian",
phone: "1-466-316-1793 x50734",
},
products: [
{
id: "869f01e6-faba-4eb8-99fe-05b0f491ee0a",
no: "PR6146",
name: "Fish",
price: 145,
qty: 24,
subTotal: 1510,
},
],
total: 1510,
createAt: "2022-06-19T09:58:52+08:00",
}
透過點擊,我們會新增一個Tab,這樣的動作無疑就是需要一個 array,因此會有一組專門記錄「開啟」的 Tabs:
const storedTabs = [{...}, {...}]
不過 {...}
實際上會儲存甚麼呢?
依照當下需求,來決定 Tab 要用到什麼資料,而按照畫面來看就是訂單ID以及訂單編號,而我們也需要知道現在是哪一個 Tab 正在瀏覽,因此多一個 currentTab
:
const currentTab = id
const storedTabs = [{id, no}, ...]
訂單ID指的是獨一無二的ID,通常都長得跟亂碼一樣(UUID);訂單編號雖然也是獨一無二,但文字上容易閱讀,通常會有明顯的格式(或邏輯),以本篇的資料格式就是指 id 與 no 。
雖然是可以,但實際上資料不一定是最新或是正確的,打API時,顯示列表你會用 GET /order
(or GET /order/list
),但若是單一筆資料你會用 GET /order/:id
,有可能在 GET /order
情況下就獲取了 99% 的所有內容,但使用上,不同使用者之間有編輯有資料操作的情況,所以仍要分開處理,才能確保使用者瀏覽時才能獲取較正確的資訊。 (不過這一塊就要看後端怎麼給惹)
這樣大致上就知道需要怎麼建立這個 Hook 了!由於有不同的操作情境,我們來用 useReducer 來建立吧!
const initValue = {
currentTab: null,
storedTabs: [],
}
function useTabs() {
const [state, dispatch] = useReducer(tabsReducer, initValue)
const tabDispatch = useCallback(
(type, payload) => dispatch({ type, payload }),
[]
)
return [state, tabDispatch]
}
initValue
定義了一開始狀態dispatch
採用了 {type, payload}
格式,為避免手殘,另外包裝成 tabDispatch(type, payload)
而重點的 reudcer 在這裡:
const tabsReducer = (state, action) => {
const type = action.type
const payload = action.payload
switch (type.toUpperCase()) {
case "ADD": {
if (state.storedTabs.some((tab) => tab.id === payload.id)) {
return {
...state,
currentTab: payload.id,
}
}
return {
...state,
currentTab: payload.id,
storedTabs: [...state.storedTabs, payload],
}
}
case "REMOVE": {
const newStoredTabs = state.storedTabs.filter((tab) => tab.id !== payload)
return {
...state,
currentTab: newStoredTabs?.at(-1)?.id || null,
storedTabs: newStoredTabs,
}
}
case "NAVIGATE": {
return {
...state,
currentTab: payload,
}
}
default: {
throw new Error("[useTabs] dispatch has received non exist type")
}
}
}
很單純,就是針對 array 進行新增與刪除的動作,我們有:
ADD
新增一個 Tab 並同時進行閱讀,若 storedTabs
已經有了,則將 currentTab
改為剛剛點擊的 idREMOVE
移除 Tab,並顯示最後一個 Tab,若已經是最後一個則為 nullNAVIGATE
點擊 Tab 時,切換到該顯示的頁面function Example() {
const [tabs, tabDispatch] = useTabs()
const isEmpty = tabs.storedTabs.length === 0
return (
<OrderBoard>
{/* 列表在這邊~~~ */}
<OrderList>
{data.map((order) => (
<OrderItem
key={order.id}
onClick={() => tabDispatch("ADD", { id: order.id, no: order.no })}
>
<Text>{order.no}</Text>
<Text>$ {order.total}</Text>
</OrderItem>
))}
</OrderList>
{/* tab 在這邊~~~ */}
<OrderViewer>
{isEmpty ? (
<Alert>
<AlertIcon />
Select a order from list to view.
</Alert>
) : (
<>
<Tabs >
<TabList>
{tabs.storedTabs.map((tab) => (
<OrderTab
key={tab.id}
isSelected={tab.id === tabs.currentTab}
onChoose={() => tabDispatch("NAVIGATE", tab.id)}
onClose={() => tabDispatch("REMOVE", tab.id)}
>
<Text>{tab.id}</Text>
</OrderTab>
))}
</TabList>
</Tabs>
</>
)}
</OrderViewer>
</OrderBoard>
)
}
太多了,挑重點看XD
左側的 List 點擊時是新增的行為,因此放上 ADD
,一併存入 {id, no}
方便等等給 tab 顯示用:
<OrderItem
key={order.id}
onClick={() => tabDispatch("ADD", { id: order.id, no: order.no })}
>
<Text>{order.no}</Text>
<Text>$ {order.total}</Text>
</OrderItem>
上排的 Tabs 則有幾個內容:
<OrderTab
key={tab.id}
isSelected={tab.id === tabs.currentTab}
onChoose={() => tabDispatch("NAVIGATE", tab.id)}
onClose={() => tabDispatch("REMOVE", tab.id)}
>
<Text>{tab.id}</Text>
</OrderTab>
NAVIAGTE
REMOVE
到目前為止會長這樣:
我們存了 id
& no
,用 id 不好閱讀,於是改用 no:
<OrderTab {...省略的props}>
<Text>{tab.no}</Text> //id 改 no
</OrderTab>
請忽略有一點 UX 不友善的地方 XD
這樣一來 tabs 部分就完成了!下一篇來弄弄 panel,看看實際如何把資料帶入 panel: